Feat: add the to_dict() - #1288
Conversation
The md_pop_one function in hashtable.h was missing a Py_DECREF(identity) call when the key was not found. This caused a reference leak on the identity object, which is particularly problematic for CIMultiDict where a new lowercase string is created for each lookup. Fixes: aio-libs#1273
Merging this PR will degrade performance by 10.68%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | test_multidict_getall_str_hit[ci-c] |
40.5 ms | 45.9 ms | -11.59% |
| ❌ | test_multidict_getall_str_hit[cs-c] |
38.4 ms | 42.9 ms | -10.37% |
| ❌ | test_cimultidict_getall_istr_hit[c] |
36.2 ms | 40.2 ms | -10.09% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing rodrigobnogueira:feat/to-dict (08e8338) with master (41c1b91)
for more information, see https://pre-commit.ci
0fe5e80 to
ed6d373
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1288 +/- ##
========================================
Coverage 99.86% 99.86%
========================================
Files 28 30 +2
Lines 3627 3729 +102
Branches 265 270 +5
========================================
+ Hits 3622 3724 +102
Misses 3 3
Partials 2 2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
About the Coveralls Coverage Metrics |
"""Benchmark comparing C vs Python implementation of to_dict()."""
import timeit
import gc
from multidict._multidict import MultiDict as CMultiDict
from multidict._multidict_py import MultiDict as PyMultiDict
def create_multidict(cls, num_keys: int, vals_per_key: int):
items = [(f"key{i % num_keys}", f"value{i}") for i in range(num_keys * vals_per_key)]
return cls(items)
def benchmark_to_dict(md, iterations: int = 1000) -> float:
# Disable GC during timing to reduce noise
gc_old = gc.isenabled()
gc.disable()
try:
def run():
md.to_dict()
return timeit.timeit(run, number=iterations)
finally:
if gc_old:
gc.enable()
def run_benchmark(num_keys: int, vals_per_key: int, iterations: int = 1000) -> None:
total_items = num_keys * vals_per_key
c_md = create_multidict(CMultiDict, num_keys, vals_per_key)
py_md = create_multidict(PyMultiDict, num_keys, vals_per_key)
c_time = benchmark_to_dict(c_md, iterations)
py_time = benchmark_to_dict(py_md, iterations)
speedup = py_time / c_time if c_time > 0 else 0
print(f"| {num_keys:>6} | {vals_per_key:>6} | {total_items:>7} | {c_time*1000:>10.2f} | {py_time*1000:>10.2f} | {speedup:>7.2f}x |")
def main() -> None:
print("\n" + "=" * 80)
print("to_dict() Benchmark: C Extension vs Pure Python")
print("=" * 80)
print(f"\n{'Iterations per test:':30} 1000")
print(f"{'Time unit:':30} milliseconds (total for 1000 calls)\n")
print("| Keys | V/Key | Total | C (ms) | Py (ms) | Speedup |")
print("|--------|--------|---------|------------|------------|---------|")
scenarios = [
(10, 1),
(10, 10),
(100, 10),
(1000, 10),
(100, 100),
# Larger datasets to target >2s execution time for Python
(2000, 20), # 40,000 items
(5000, 10), # 50,000 items
]
for num_keys, vals_per_key in scenarios:
run_benchmark(num_keys, vals_per_key)
print("\n" + "=" * 80)
print("Speedup = Python time / C time (higher is better for C)")
print("=" * 80 + "\n")
if __name__ == "__main__":
main()to_dict() Benchmark: C Extension vs Pure PythonIterations per test: 1000
=====================================
|
There was a problem hiding this comment.
I would've applied some smarter logic like traverse all keys and gathering all the values up with something like getall(...) when going over keys since it would be a bit faster due to less time needing to check weather or not something is a list. Otherwise I think this is a good start in the right direction. There might be a quicker method though that you can try which I've attempted to visualize for you although I haven't benchmarked it but just incase here was my idea.
It's advantages mainly revolve around not needing to check on weather or not a key has already been used.
from multidict import MultiDict
# incase you need a visual of the logic I'm trying to explain
def to_dict(md: MutliDict[str]):
return {k: md.getall(k) for k in md.keys()}|
Hello @Vizonex , I've posted about using I haven't look into the Using the ================================================================================
|
| Keys | V/Key | Total | C (ms) | Py (ms) | Speedup |
|---|---|---|---|---|---|
| 10 | 1 | 10 | 0.93 | 18.91 | 20.27x |
| 10 | 10 | 100 | 5.66 | 495.79 | 87.56x |
| 100 | 10 | 1000 | 41.58 | 4644.99 | 111.72x |
| 1000 | 10 | 10000 | 424.65 | 53012.13 | 124.84x |
================================================================================
Speedup = Python time / C time (higher is better for C)
Vizonex
left a comment
There was a problem hiding this comment.
Converter looks good. I have no complaints with this one. Great job with the pytest module also.
|
@rodrigobnogueira note that there's no need to use conventional commit prefixes as they only eat up space. Towncrier uses change note-based identifiers in file names. |
|
@rodrigobnogueira Do you think anything here could be pulled into aio-libs/aiohttp#7679? |
I'm guessing probably not, as it produces a list, rather than a concatenated string. I wonder how useful this feature will actually be (i.e. a user could just use a dict of lists in the first place if that's what they want...). |
I opened this PR a few months ago to address the 2022 issue requesting this feature. If this isn't the direction we want to take, I’m fine with closing it alongside #783 to avoid adding unnecessary complexity to the library. The value of this feature is probably a more performant |
I don't have a strong opinion, though it'd be nice to know if this is a somewhat common usecase for people. I'll let someone else decide if this is a good idea or not. |
@Dreamsorcerer I think it would be good for libraries such as msgspec to have this feature implemented. I could see some use-cases such as sterilizing header files for debugging an http request for instance or for other configuration systems. |
Use-case evidence, since that was the open question: this mirrors an established convenience on multidict-shaped types. Werkzeug's MultiDict has exactly this as to_dict(flat=False), and Django's QueryDict exposes it through .lists(). Meanwhile all review notes are addressed, to_dict() is now documented in the API reference, and the full suite is green on 3.12 and 3.13t. |
Adding to_dict() as an abstractmethod on MultiMapping made every existing downstream subclass fail to instantiate, since MultiMapping is public and documented. A class implementing everything previously required raised "Can't instantiate abstract class ... without an implementation for abstract method 'to_dict'". It is a concrete default now, documented as needing an override for mappings that fold keys. The C append path looked the list up again with PyDict_GetItem(result, first_key). That swallows any exception from hashing the key, which can be a str subclass with its own __hash__, so a miss returned NULL with nothing set and CPython would raise SystemError. The pure-Python side raised KeyError there instead, so the backends disagreed. Both now key the seen map by the list itself, which removes the second lookup rather than guarding it, and drops one dict lookup per entry on both backends. The pure-Python side also stopped building two istr objects per key. The leak script measured RSS over interned keys and small ints, so a leaked reference to the key, identity or value moved it by zero bytes. It compares refcounts against a baseline now, over non-interned keys and plain objects, and covers CIMultiDict, which is the only allocating path. Injecting a stray Py_INCREF on the append path makes it fail with "value leaked: [0, 0, 1000]"; the old script passed with that leak in place. The changelog fragment gained its .rst extension, so the docs spell check and the :user: role hook actually see it, and the docs now contrast to_dict() with dict(md) and with the getall() comprehension that gets case-insensitive keys wrong.
| gc.collect() | ||
| baselines = [sys.getrefcount(v) for v in (value_1, value_2, value_3)] | ||
| for _ in range(1000): | ||
| _d = md.to_dict() |
| gc.collect() | ||
| key_baselines = [sys.getrefcount(k) for k in (key_a, key_b)] | ||
| for _ in range(1000): | ||
| _d = md.to_dict() |
| gc.collect() | ||
| ci_baselines = [sys.getrefcount(v) for v in (value_1, value_2)] | ||
| for _ in range(1000): | ||
| _d = ci.to_dict() |
What do these changes do?
Implement
to_dict()methods forMultiDict,CIMultiDict,MultiDictProxy, andCIMultiDictProxy. This method groups values with the same key into a list.Implementation Details
multidict_to_dictin_multidict.cwith directPyDictandPyListmanipulation for performance.to_dictin_multidict_py.py.tests/test_to_dict.py, including verification of order preservation, case-insensitivity, mixed types, and proxy mutation isolation.tests/isolated/multidict_to_dict.py) and integrated it into the CI suite (tests/test_leaks.py). Validated with intentional leak inserted in code (removed after test).Example
Are there changes in behavior for the user?
No existing behavior changes. This adds a new method
to_dict()Related issue number
Fixes #783 (Add
to_dictmethod)Checklist